/**
 * @license
 * Copyright 2022 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Colour input field with HSV sliders.
 */

// import * as Blockly from 'blockly/core';
// import { FieldColour } from '@blockly/field-colour';

// Experimental API: https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper
// declare interface EyeDropper {
//   open: () => Promise<{sRGBHex}>;
// }
// declare global {
//   interface Window {
//     EyeDropper?: {new ()};
//   }
// }

/**
 * A structure with three properties r, g, and b, representing the amount of
 * red, green, and blue light in the sRGB colour space where 1 is the maximum
 * amount of light that can be displayed.
 */
class RgbColour {
    /** The red component of the colour, ranging from 0 to 1. */
    r;

    /** The green component of the colour, ranging from 0 to 1. */
    g;

    /** The blue component of the colour, ranging from 0 to 1. */
    b;

    /**
     * The RgbColour constructor.
     *
     * @param r The initial amount of red. Defaults to 0.
     * @param g The initial amount of green. Defaults to 0.
     * @param b The initial amount of blue. Defaults to 0.
     */
    constructor(r = 0, g = 0, b = 0) {
        this.r = r;
        this.g = g;
        this.b = b;
    }

    /**
     * Given a number from 0 to 1, returns a two-digit hexadecimal string from
     * '00' to 'ff'.
     *
     * @param x The amount of light in a component from 0 to 1.
     * @returns A hexadecimal representation from '00' to 'ff'.
     */
    static componentToHex(x) {
        if (x <= 0) return '00';
        if (x >= 1) return 'ff';
        return ('0' + ((x * 255 + 0.5) >>> 0).toString(16)).slice(-2);
    }

    /**
     * Returns a hexadecimal string in the format #rrggbb representing the colour.
     *
     * @returns A hexadecimal representation of this colour.
     */
    toHex() {
        return (
            '#' +
            RgbColour.componentToHex(this.r) +
            RgbColour.componentToHex(this.g) +
            RgbColour.componentToHex(this.b)
        );
    }

    /**
     * Updates the properties of this instance to represent the same colour as the
     * provided string in the hexadecimal format #rrggbb.
     *
     * @param hex A hexadecimal string in the format '#rrggbb'.
     * @returns This instance after updating it.
     */
    loadFromHex(hex) {
        this.r = parseInt(hex.slice(1, 3), 16) / 255;
        this.g = parseInt(hex.slice(3, 5), 16) / 255;
        this.b = parseInt(hex.slice(5, 7), 16) / 255;
        return this;
    }

    /**
     * Updates the properties of this instance to represent the same colour as the
     * provided HsvColour but in the sRGB colour space.
     *
     * @param hsv An HSV representation of a colour to copy.
     * @returns This instance after updating it.
     */
    loadFromHsv(hsv) {
        const hue = (hsv.h - Math.floor(hsv.h)) * 6;
        this.r =
            hsv.v * (1 - hsv.s * Math.max(0, Math.min(1, 2 - Math.abs(hue - 3))));
        this.g =
            hsv.v * (1 - hsv.s * Math.max(0, Math.min(1, Math.abs(hue - 2) - 1)));
        this.b =
            hsv.v * (1 - hsv.s * Math.max(0, Math.min(1, Math.abs(hue - 4) - 1)));
        return this;
    }
}

/**
 * A structure with three properties h, s, and v, representing the hue,
 * saturation, and brightness in a colour. All three properties range from 0
 * to 1.
 */
class HsvColour {
    /** The hue of the colour, ranging from 0 to 1. */
    h;

    /** The saturation of the colour, ranging from 0 to 1. */
    s;

    /** The brightness of the colour, ranging from 0 to 1. */
    v;

    /**
     * The HsvColour constructor.
     *
     * @param h The initial hue of the colour. Defaults to 0.
     * @param s The initial amount of saturation. Defaults to 0.
     * @param v The initial amount of brightness. Defaults to 0.
     */
    constructor(h = 0, s = 0, v = 0) {
        this.h = h;
        this.s = s;
        this.v = v;
    }

    /**
     * Updates the properties of this instance to represent the same colour as the
     * provided RgbColour but in the HSV colour space.
     *
     * @param rgb An RGB representation of a colour to copy.
     * @returns This instance after updating it.
     */
    loadFromRgb(rgb) {
        const max = Math.max(Math.max(rgb.r, rgb.g), rgb.b);
        const min = Math.min(Math.min(rgb.r, rgb.g), rgb.b);
        this.v = max;
        if (min === max) {
            this.h = 0;
            this.s = 0;
            return this;
        }

        const delta = max - min;
        this.s = delta / max;

        let hue;
        if (rgb.r === max) {
            hue = (rgb.g - rgb.b) / delta;
        } else if (rgb.g === max) {
            hue = 2 + (rgb.b - rgb.r) / delta;
        } else {
            hue = 4 + (rgb.r - rgb.g) / delta;
        }
        hue /= 6;
        this.h = hue - Math.floor(hue);
        return this;
    }

    /**
     * Updates the properties of this instance to copy the provided HsvColour.
     *
     * @param other An HSV representation of a colour to copy.
     * @returns This instance after updating it.
     */
    copy(other) {
        this.h = other.h;
        this.s = other.s;
        this.v = other.v;
        return this;
    }
}

/**
 * Class for a colour input field that displays HSV slider widgets when clicked.
 */
export class FieldColourHsvSliders extends Blockly.FieldColour {
    /* eslint-disable @typescript-eslint/naming-convention */
    /** The maximum value of the hue slider range. */
    static HUE_SLIDER_MAX = 360;

    /** The maximum value of the saturation slider range. */
    static SATURATION_SLIDER_MAX = 100;

    /** The maximum value of the brightness slider range. */
    static BRIGHTNESS_SLIDER_MAX = 100;

    /**
     * The gradient control point positions should align with the center of the
     * slider thumb when the corresponding colour is selected. When the slider
     * is at the minimum or maximum value, the distance of center of the thumb
     * from the edge of the track will be the thumb's radius, so that's how far
     * the minimum and maximum control points should be.
     */
    static THUMB_RADIUS = 12;
    /* eslint-enable @typescript-eslint/naming-convention */

    /** Helper colour structures to allow manipulation in the HSV colour space. */
    static helperHsv = new HsvColour();

    /** Helper colour structures to support conversion to the RGB colour space. */
    static helperRgb = new RgbColour();

    /**
     * The intial value of the field when the user opened an editor to change its
     * value. When the editor is disposed, an event will be fired that uses this
     * as the event's oldValue.
     */
    valueWhenEditorWasOpened = null;

    /** Array holding info needed to unbind events. Used for disposing. */
    hsvBoundEvents = [];

    /** HTML span element to display the current hue. */
    hueReadout = null;

    /** HTML range input element for editing hue. */
    hueSlider = null;

    /** HTML span element to display the current saturation. */
    saturationReadout = null;

    /** HTML range input element for editing saturation. */
    saturationSlider = null;

    /** HTML span element to display the current brightness. */
    brightnessReadout = null;

    /** HTML range input element for editing brightness. */
    brightnessSlider = null;

    /** HTML div element containing all the labels and sliders. */
    dropdownContainer = null;

    /* eslint-disable @typescript-eslint/naming-convention */
    /**
     * Create and show the colour field's editor.
     *
     * @override
     */
    showEditor_() {
        /* eslint-enable @typescript-eslint/naming-convention */
        this.createDropdownSliders();
        if (!this.dropdownContainer || !this.hueSlider) {
            throw new Error('Failed to initialize the HSV sliders.');
        }
        Blockly.DropDownDiv.getContentDiv().appendChild(this.dropdownContainer);

        Blockly.DropDownDiv.showPositionedByField(
            this,
            this.dropdownDisposeSliders.bind(this),
        );

        this.valueWhenEditorWasOpened = this.value_;

        // Focus so we can start receiving keyboard events.
        this.hueSlider.focus({ preventScroll: true });
    }

    /**
     * Creates a row with a slider label and a readout to display the slider
     * value, appends it to the provided container, and returns the readout.
     *
     * @param name The display name of the slider.
     * @param container Where the row will be inserted.
     * @returns The readout, so that it can be updated.
     */
    static createLabelInContainer(
        name,
        container,
    ) {
        const label = document.createElement('div');
        const labelText = document.createElement('span');
        const readout = document.createElement('span');
        label.classList.add('fieldColourSliderLabel');
        labelText.textContent = name;
        label.appendChild(labelText);
        label.appendChild(readout);
        container.appendChild(label);
        return readout;
    }

    /**
     * Creates a slider, appends it to the provided container, and returns it.
     *
     * @param max The maximum value of the slider.
     * @param step The minimum step size of the slider.
     * @param container Where the row slider be inserted.
     * @returns The slider.
     */
    static createSliderInContainer(
        max,
        step,
        container,
    ) {
        const slider = document.createElement('input');
        slider.classList.add('fieldColourSlider');
        slider.type = 'range';
        slider.min = String(0);
        slider.max = String(max);
        slider.step = String(step);
        container.appendChild(slider);
        return slider;
    }

    /** Creates the colour picker slider editor and adds event listeners. */
    createDropdownSliders() {
        const container = document.createElement('div');
        container.classList.add('fieldColourSliderContainer');

        this.hueReadout = FieldColourHsvSliders.createLabelInContainer(
            'Hue',
            container,
        );
        this.hueSlider = FieldColourHsvSliders.createSliderInContainer(
            FieldColourHsvSliders.HUE_SLIDER_MAX,
            2,
            container,
        );
        this.saturationReadout = FieldColourHsvSliders.createLabelInContainer(
            'Saturation',
            container,
        );
        this.saturationSlider = FieldColourHsvSliders.createSliderInContainer(
            FieldColourHsvSliders.SATURATION_SLIDER_MAX,
            1,
            container,
        );
        this.brightnessReadout = FieldColourHsvSliders.createLabelInContainer(
            'Brightness',
            container,
        );
        this.brightnessSlider = FieldColourHsvSliders.createSliderInContainer(
            FieldColourHsvSliders.BRIGHTNESS_SLIDER_MAX,
            1,
            container,
        );

        this.hsvBoundEvents.push(
            Blockly.browserEvents.conditionalBind(
                this.hueSlider,
                'input',
                this,
                this.onSliderChange,
            ),
        );
        this.hsvBoundEvents.push(
            Blockly.browserEvents.conditionalBind(
                this.saturationSlider,
                'input',
                this,
                this.onSliderChange,
            ),
        );
        this.hsvBoundEvents.push(
            Blockly.browserEvents.conditionalBind(
                this.brightnessSlider,
                'input',
                this,
                this.onSliderChange,
            ),
        );

        if (window.EyeDropper) {
            // If the browser supports the eyedropper API, create a button for it.
            const button = document.createElement('button');
            button.classList.add('fieldColourEyedropper');
            container.appendChild(document.createElement('hr'));
            container.appendChild(button);
            this.hsvBoundEvents.push(
                Blockly.browserEvents.conditionalBind(
                    button,
                    'click',
                    this,
                    this.onEyedropperEvent,
                ),
            );
        }

        this.dropdownContainer = container;

        this.updateSliderValues();
    }

    /** Disposes of events and DOM-references belonging to the colour editor. */
    dropdownDisposeSliders() {
        for (const event of this.hsvBoundEvents) {
            Blockly.browserEvents.unbind(event);
        }
        this.hsvBoundEvents.length = 0;
        this.hueReadout = null;
        this.hueSlider = null;
        this.saturationReadout = null;
        this.saturationSlider = null;
        this.brightnessReadout = null;
        this.brightnessSlider = null;
        this.dropdownContainer = null;

        if (
            this.sourceBlock_ &&
            Blockly.Events.isEnabled() &&
            this.valueWhenEditorWasOpened !== null &&
            this.valueWhenEditorWasOpened !== this.value_
        ) {
            // When closing a field input widget, fire an event indicating that the
            // user has completed a sequence of changes. The value may have changed
            // multiple times while the editor was open, but this will fire an event
            // containing the value when the editor was opened as well as the new one.
            Blockly.Events.fire(
                new (Blockly.Events.get(Blockly.Events.BLOCK_CHANGE))(
                    this.sourceBlock_,
                    'field',
                    this.name || null,
                    this.valueWhenEditorWasOpened,
                    this.value_,
                ),
            );
            this.valueWhenEditorWasOpened = null;
        }
    }

    /**
     * A helper function that converts a colour, specified by the provided hue,
     * saturation, and brightness parameters, into a hexadecimal string in the
     * format "#rrggbb".
     *
     * @param hue The hue of the colour.
     * @param saturation The saturation of the colour.
     * @param brightness The brightness of the colour.
     * @returns A hexadecimal representation of the colour in the format "#rrggbb"
     */
    static hsvToHex(
        hue,
        saturation,
        brightness,
    ) {
        FieldColourHsvSliders.helperHsv.h = hue;
        FieldColourHsvSliders.helperHsv.s = saturation;
        FieldColourHsvSliders.helperHsv.v = brightness;
        return FieldColourHsvSliders.helperRgb
            .loadFromHsv(FieldColourHsvSliders.helperHsv)
            .toHex();
    }

    /**
     * Updates the value of this field based on the editor sliders.
     *
     * @param event Unused.
     */
    onSliderChange(event) {
        if (!this.hueSlider || !this.saturationSlider || !this.brightnessSlider) {
            throw new Error('The HSV sliders are missing.');
        }
        const hue =
            parseFloat(this.hueSlider.value) / FieldColourHsvSliders.HUE_SLIDER_MAX;
        const saturation =
            parseFloat(this.saturationSlider.value) /
            FieldColourHsvSliders.SATURATION_SLIDER_MAX;
        const brightness =
            parseFloat(this.brightnessSlider.value) /
            FieldColourHsvSliders.BRIGHTNESS_SLIDER_MAX;
        this.setIntermediateValue(
            FieldColourHsvSliders.hsvToHex(hue, saturation, brightness),
        );
        this.renderSliders();
    }

    /**
     * Updates the value of this field and editor sliders using an eyedropper.
     *
     * @param event Unused.
     */
    onEyedropperEvent(event) {
        if (window.EyeDropper) {
            const eyeDropper = new window.EyeDropper();
            eyeDropper.open().then((result) => {
                this.setIntermediateValue(result.sRGBHex);
                this.updateSliderValues();
            });
        }
    }

    setIntermediateValue(value) {
        // Intermediate value changes from user input are not confirmed until the
        // user closes the editor, and may be numerous. Inhibit reporting these as
        // normal block change events, and instead report them as special
        // intermediate changes that do not get recorded in undo history.
        const oldValue = this.value_;
        // Change the field's value without firing the normal change event.
        this.setValue(value, false);
        if (
            this.sourceBlock_ &&
            Blockly.Events.isEnabled() &&
            this.value_ !== oldValue
        ) {
            // Fire a special event indicating that the value changed but the change
            // isn't complete yet and normal field change listeners can wait.
            Blockly.Events.fire(
                new (Blockly.Events.get(
                    Blockly.Events.BLOCK_FIELD_INTERMEDIATE_CHANGE,
                ))(this.sourceBlock_, this.name || null, oldValue, this.value_),
            );
        }
    }

    /**
     * Updates the gradient backgrounds of the slider tracks and readouts based
     * on the slider values.
     */
    renderSliders() {
        if (
            !this.hueSlider ||
            !this.hueReadout ||
            !this.saturationSlider ||
            !this.saturationReadout ||
            !this.brightnessSlider ||
            !this.brightnessReadout
        ) {
            throw new Error('The HSV sliders are missing.');
        }
        this.hueReadout.textContent = this.hueSlider.value;
        this.saturationReadout.textContent = this.saturationSlider.value;
        this.brightnessReadout.textContent = this.brightnessSlider.value;

        const h =
            parseFloat(this.hueSlider.value) / FieldColourHsvSliders.HUE_SLIDER_MAX;
        const s =
            parseFloat(this.saturationSlider.value) /
            FieldColourHsvSliders.SATURATION_SLIDER_MAX;
        const v =
            parseFloat(this.brightnessSlider.value) /
            FieldColourHsvSliders.BRIGHTNESS_SLIDER_MAX;

        // The hue slider needs intermediate gradient control points to include all
        // colours of the rainbow.
        let hueGradient = 'linear-gradient(to right, ';
        hueGradient +=
            FieldColourHsvSliders.hsvToHex(0 / 6, s, v) +
            ` ${FieldColourHsvSliders.THUMB_RADIUS}px, `;
        hueGradient += FieldColourHsvSliders.hsvToHex(1 / 6, s, v) + ', ';
        hueGradient += FieldColourHsvSliders.hsvToHex(2 / 6, s, v) + ', ';
        hueGradient += FieldColourHsvSliders.hsvToHex(3 / 6, s, v) + ', ';
        hueGradient += FieldColourHsvSliders.hsvToHex(4 / 6, s, v) + ', ';
        hueGradient += FieldColourHsvSliders.hsvToHex(5 / 6, s, v) + ', ';
        hueGradient +=
            FieldColourHsvSliders.hsvToHex(6 / 6, s, v) +
            ` calc(100% - ${FieldColourHsvSliders.THUMB_RADIUS}px))`;
        this.hueSlider.style.setProperty('--slider-track-background', hueGradient);

        // The saturation slider only needs gradient control points at each end.
        let saturationGradient = 'linear-gradient(to right, ';
        saturationGradient +=
            FieldColourHsvSliders.hsvToHex(h, 0, v) +
            ` ${FieldColourHsvSliders.THUMB_RADIUS}px, `;
        saturationGradient +=
            FieldColourHsvSliders.hsvToHex(h, 1, v) +
            ` calc(100% - ${FieldColourHsvSliders.THUMB_RADIUS}px))`;
        this.saturationSlider.style.setProperty(
            '--slider-track-background',
            saturationGradient,
        );

        // The brightness slider only needs gradient control points at each end.
        let brightnessGradient = 'linear-gradient(to right, ';
        brightnessGradient +=
            FieldColourHsvSliders.hsvToHex(h, s, 0) +
            ` ${FieldColourHsvSliders.THUMB_RADIUS}px, `;
        brightnessGradient +=
            FieldColourHsvSliders.hsvToHex(h, s, 1) +
            ` calc(100% - ${FieldColourHsvSliders.THUMB_RADIUS}px))`;
        this.brightnessSlider.style.setProperty(
            '--slider-track-background',
            brightnessGradient,
        );
    }

    /** Updates slider values based on the current value of the field. */
    updateSliderValues() {
        if (!this.hueSlider || !this.saturationSlider || !this.brightnessSlider) {
            return;
        }

        const hsv = FieldColourHsvSliders.helperHsv.loadFromRgb(
            FieldColourHsvSliders.helperRgb.loadFromHex(this.getValue() ?? ''),
        );

        this.hueSlider.value = String(hsv.h * FieldColourHsvSliders.HUE_SLIDER_MAX);
        this.saturationSlider.value = String(
            hsv.s * FieldColourHsvSliders.SATURATION_SLIDER_MAX,
        );
        this.brightnessSlider.value = String(
            hsv.v * FieldColourHsvSliders.BRIGHTNESS_SLIDER_MAX,
        );

        this.renderSliders();
    }
}

Blockly.fieldRegistry.register(
    'field_colour_hsv_sliders',
    FieldColourHsvSliders,
);

// CSS for colour slider fields.
Blockly.Css.register(`
.fieldColourSliderContainer {
  padding: 4px;
}
.fieldColourSliderContainer hr {
  border: none;
  border-top: 1px solid #bbb;
}
.fieldColourSliderLabel {
  display: flex;
  justify-content: space-between;
}
.fieldColourEyedropper {
  appearance: none;
  position: relative;
  border: none;
  border-radius: 4px;
  background: transparent;
  font: inherit;
  color: inherit;
  cursor: pointer;
  width: 100%;
  margin: 0;
  display: flex;
  justify-content: center;
  align-items: center;
}
.fieldColourEyedropper:hover {
  background: rgba(0,0,0,0.1)
}
.fieldColourEyedropper input {
  opacity: 0;
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
}
.fieldColourEyedropper::before {
  content: "Eyedropper";
}
.fieldColourEyedropper::after {
  content: "";
  margin-left: 8px;
  width: 24px;
  height: 24px;
  background: currentColor;
  pointer-events: none;
  -webkit-mask-image: var(--customize-dial-symbol);
  -webkit-mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-image: var(--customize-dial-symbol);
  mask-repeat: no-repeat;
  mask-position: center;
  --customize-dial-symbol: url('data:image/svg+xml,\
    <svg xmlns="http://www.w3.org/2000/svg" \
         width="24px" height="24px" \
         viewBox="0 0 24 24"> \
      <path stroke="black" strokewidth="1.414" fill="none" \
            d="m 13 8 L 6 15 Q 3 18 2 21 Q 0 23 .5 23.5 Q 1 24 3 22 \
                Q 6 21 9 18 L 16 11"/> \
      <path fill="black" \
            d="m 12 7 Q 11 6 12 5 Q 13 4 14 5 Q 15 6 16 5 Q 20 -1 22.5 1.5 \
                Q 25 4 19 8 Q 18 9 19 10 Q 20 11 19 12 Q 18 13 17 12"/> \
    </svg>');
}
.fieldColourSlider {
  -webkit-appearance: none;
  width: 150px;
  height: 24px;
  margin: 4px 8px 24px 8px;
  padding: 0;
}
.fieldColourSlider:last-child {
  margin-bottom: 4px;
}
.fieldColourSlider:focus {
  outline: none;
}
/* Webkit */
.fieldColourSlider::-webkit-slider-runnable-track {
  background: var(--slider-track-background);
  border-radius: 8px;
  height: 16px;
}
.fieldColourSlider::-webkit-slider-thumb {
  -webkit-appearance: none;
  background: #fff;
  border-radius: 50%;
  box-shadow: 0 0 0 4px rgba(0,0,0,.15);
  cursor: pointer;
  width: ${FieldColourHsvSliders.THUMB_RADIUS * 2}px;
  height: ${FieldColourHsvSliders.THUMB_RADIUS * 2}px;
  margin-top: -4px;
}
/* Firefox */
.fieldColourSlider::-moz-range-track {
  background: var(--slider-track-background);
  border-radius: 8px;
  height: 16px;
}
.fieldColourSlider::-moz-range-thumb {
  background: #fff;
  border: none;
  border-radius: 50%;
  box-shadow: 0 0 0 4px rgba(0,0,0,.15);
  cursor: pointer;
  width: ${FieldColourHsvSliders.THUMB_RADIUS * 2}px;
  height: ${FieldColourHsvSliders.THUMB_RADIUS * 2}px;
}
.fieldColourSlider::-moz-focus-outer {
  /* override the focus border style */
  border: 0;
}
/* IE */
.fieldColourSlider::-ms-track {
  background: var(--slider-track-background);
  border-radius: 12px;
  width: 100%;
  height: 24px;
  /* remove default tick marks */
  color: transparent;
}
.fieldColourSlider::-ms-fill-lower  {
  background: transparent;
}
.fieldColourSlider::-ms-fill-upper  {
  background: transparent;
}
.fieldColourSlider::-ms-thumb {
  background: #fff;
  border: none;
  border-radius: 50%;
  box-shadow: 0 0 0 4px rgba(0,0,0,.15);
  cursor: pointer;
  width: ${FieldColourHsvSliders.THUMB_RADIUS * 2}px;
  height: ${FieldColourHsvSliders.THUMB_RADIUS * 2}px;
}
`);